Anna Jurkiewicz 116679
Dominika Kaczmarska 116977
studia niestacjonarne sobotnio - niedzielne
Projekt z przedmiotu "Podstawy Aproksymacji - od analizy Fouriera po deep learning"
Słuchając muzyki, często ograniczamy się do słuchania sprawdzonej playlisty. Ograniczając się do lubianej playlisty, ciężko śledzić nowości, a przesłuchane piosenki X razy zaczynają się nam nudzić.
A co gdyby móc z playlisty zawierającej nowości wybrać tylko te, które są zgodne z naszym gustem? Oszczędzimy czas spędzony na omijaniu piosenek, które po przesłuchania kawałka nie do końca są w naszym guście, a do tego będziemy mogli ciągle znajdować piosenki, które będą miłe dla naszego ucha.
Aby stworzyć model, który będzie wybierał piosenki, które nam się spodobają, potrzebujemy dwa zbiory:
W celu pozyskania danych korzystamy z danych, które możemy pobrać z naszego konta na Spotify. Oprócz informacji takich jak tytuł i wykonawca, Spotify udostępnia wiele parametrów piosenek, np. taneczność, głośność, muzykalność.
Jako piosenki, które nam się spodobają, najłatwiej wziąć zbiór piosenek 'zaserduszkowanych'. Jeśli chodzi o zbiór utworów, które nie są w naszym guście postanowiłyśmy kierować się dwoma gatunkami: Disco i Heavy Metal. Gatunki się bardzo różnią od siebie, aczkolwiek są tak samo nie lubiane przez nas.
Spotify umożliwia pobieranie tych playlist poprzez stworzenie aplikacji nadającej dostęp do konta, zapewnionej przez "Spotify for Dewelopers". Niestety Web API Wrappers w Julii jest jeszcze w fazie testów, więc w celu pobrania danych skorzystałyśmy z Pythona. Wygenerowane dane wgrywamy do Julii jako CSV.
using DataFrames
using CSV
using StatsBase
ulubione = CSV.read("C:\\Users\\domi\\Desktop\\studia_sgh\\sem2\\ulubione.csv", DataFrame);
disco = CSV.read("C:\\Users\\domi\\Desktop\\studia_sgh\\sem2\\disco.csv", DataFrame);
metal = CSV.read("C:\\Users\\domi\\Desktop\\studia_sgh\\sem2\\metal.csv", DataFrame);
Zobaczmy jak wygląda struktura takiego zbioru
describe(ulubione)
Parametry utworów, których będziemy używać do analizy, to:
Przygotujmy tabele zawierające te parametry oraz ustalmy ich typy na Float64
ulubione[!, :danceability] = convert(Vector{Float64}, ulubione[!, :danceability]);
ulubione[!, :energy] = convert(Vector{Float64}, ulubione[!, :energy]);
ulubione[!, :speechiness] = convert(Vector{Float64}, ulubione[!, :speechiness]);
ulubione[!, :acousticness] = convert(Vector{Float64}, ulubione[!, :acousticness]);
ulubione[!, :instrumentalness] = convert(Vector{Float64}, ulubione[!, :instrumentalness]);
ulubione[!, :tempo] = convert(Vector{Float64}, ulubione[!, :tempo]);
ulubione[!, :loudness] = convert(Vector{Float64}, ulubione[!, :loudness]);
ulubione[!, :liveness] = convert(Vector{Float64}, ulubione[!, :liveness]);
ulubione[!, :valence] = convert(Vector{Float64}, ulubione[!, :valence]);
ulubione = select(ulubione, :danceability, :energy, :speechiness, :acousticness, :instrumentalness, :tempo, :loudness, :liveness, :valence);
metal[!, :danceability] = convert(Vector{Float64}, metal[!, :danceability]);
metal[!, :energy] = convert(Vector{Float64}, metal[!, :energy]);
metal[!, :speechiness] = convert(Vector{Float64}, metal[!, :speechiness]);
metal[!, :acousticness] = convert(Vector{Float64}, metal[!, :acousticness]);
metal[!, :instrumentalness] = convert(Vector{Float64}, metal[!, :instrumentalness]);
metal[!, :tempo] = convert(Vector{Float64}, metal[!, :tempo]);
metal[!, :loudness] = convert(Vector{Float64}, metal[!, :loudness]);
metal[!, :liveness] = convert(Vector{Float64}, metal[!, :liveness]);
metal[!, :valence] = convert(Vector{Float64}, metal[!, :valence]);
metal = select(metal, :danceability, :energy, :speechiness, :acousticness, :instrumentalness, :tempo, :loudness, :liveness, :valence);
disco[!, :danceability] = convert(Vector{Float64}, disco[!, :danceability]);
disco[!, :energy] = convert(Vector{Float64}, disco[!, :energy]);
disco[!, :speechiness] = convert(Vector{Float64}, disco[!, :speechiness]);
disco[!, :acousticness] = convert(Vector{Float64}, disco[!, :acousticness]);
disco[!, :instrumentalness] = convert(Vector{Float64}, disco[!, :instrumentalness]);
disco[!, :tempo] = convert(Vector{Float64}, disco[!, :tempo]);
disco[!, :loudness] = convert(Vector{Float64}, disco[!, :loudness]);
disco[!, :liveness] = convert(Vector{Float64}, disco[!, :liveness]);
disco[!, :valence] = convert(Vector{Float64}, disco[!, :valence]);
disco = select(disco, :danceability, :energy, :speechiness, :acousticness, :instrumentalness, :tempo, :loudness, :liveness, :valence);
Ze względu na to, że parametry mają różne zbiory wartości, wszystkie kolumny poddamy procedurze standaryzacji
ulubione = mapcols(zscore, ulubione);
disco = mapcols(zscore, disco);
metal = mapcols(zscore, metal);
Stwórzmy dwa zbiory lubiane i nie_lubiane, w których dodamy kolumnę like z wartościami:
lubiane = ulubione;
insertcols!(lubiane,
1,
:like => 1.0);
first(lubiane, 5)
nrow(lubiane)
Zbiór lubianych zawiera 398 obserwacji.
nie_lubiane = vcat(disco, metal);
insertcols!(nie_lubiane,
1,
:like => 0.0);
first(nie_lubiane, 5)
nrow(nie_lubiane)
Zbiór nielubianych piosenek zawiera 656 obserwacji.
W celu sprawdzenia, czy te parametry będą przyjmować różne wartości dla lubianych i nielubianych wartości, dla obu zbiorów tworzymy cornetploty oraz patrzymy czy rozkłady dla parametrów są od siebie różne. Ze względu na to, że pokazując wykresy dla wszystkich parametrów tracimy na czytelności, podzielimy parametry na dwie grupy.
using StatsPlots
1.1) Cornerplot zbioru lubiane dla parametrów: acousticness, speechiness, energy, danceability
@df lubiane cornerplot(cols(2:5))
1.2) Cornerplot zbioru nie_lubiane dla parametrów: acousticness, speechiness, energy, danceability
@df nie_lubiane cornerplot(cols(2:5))
2.1) Cornerplot zbioru lubiane dla parametrów: instrumentalness, tempo, loudness, liveness, valence
@df lubiane cornerplot(cols(6:10))
2.2) Cornerplot zbioru nie_lubiane dla parametrów: instrumentalness, tempo, loudness, liveness, valence
@df nie_lubiane cornerplot(cols(6:10))
Możemy zauważyć, dla każdego zbioru parametry mają inne charakterystyki. W celu potwierdzenia porównujemy podstawowe statystyki obu zbiorów(min,max,mediana,średnia).
describe(lubiane)
describe(nie_lubiane)
Możemu zauważyć w obu grupach statystyki różnią się od siebie.
Aby zbudować model, łączymy nasze zbiory w jeden data frame.
df = vcat(nie_lubiane, lubiane);
Ze względu na to, że nasz zbiór ma mniejszą liczbę 'jedynek' (utwór, które lubimy) od 'zer' (utwór, których nie lubimy) dzieląc zbiór na zbiór uczący i testowy użyjemy funkcji stratifiedobs. Dzięki niej mamy gwarancję, że proporcja jedynek do zer w obu zbiór będzie taka sama. Zbiory dzielimy w proporcji 70:30 (uczący:testowy)
using MLDataUtils
using Flux
using Statistics
using LinearAlgebra
using Metrics
using Flux: crossentropy, onecold, onehotbatch, params, train!
(X_train,y_train), (X_test,y_test) = stratifiedobs((df[:, 2:end], df[!, :like]), p = 0.7);
X_train = Matrix(X_train)';
X_test = Matrix(X_test)';
y_train = onehotbatch(y_train, 0:1)
y_test = onehotbatch(y_test, 0:1)
Model, którymy się posłużymy to sieć klasyfikacyjna. Jest to odmiana sieci neuronowej, w której sygnały wyjściowe mają chrakter jakościowy.
Struktura obejmue warstwę wejściową, warstawę ukrytą oraz warstwe wyjściową. Za funckje aktywacji dla warstwy wejściowej i ukrytej przyjmujemy funkcje relu6. relu6(x)=min(max(0,x),6).
Warstwa wejściowa zawiera 9 neuronów, warstwa ukryta posiada 4 neurony oraz warstwa wyjściowa 2 neurony. Połączenie(dense) warstwy wejściowej z warstwą ukrytą daje nam 40 parametrów, a połączenie warstwy ukrytej z warstwą wyjściową daje nam 10 parametrów.
Dla warstwy wyjściowej przyjmujemy funckje aktywacji - softmax.
model = Chain(
Dense(9, 4, relu6),
Dense(4 ,2),
softmax
)
Za funkcje straty przyjmujemy binary_focal_loss.
loss(x, y)= Flux.binary_focal_loss(model(x), y)
loss(X_train, y_train)
Dla naszej sieci wagi wybierane są z góry losowo.
ps = params(model)
Za optymalizator przyjmujemy funkcję NADAM - Nesterovą odmiane ADAMa. Parametry nie wymagają dostrajania.
opt = NADAM()
Jako że neurony w warstwie wyjściowej przyjmują wartości binarne, do policzenia wartości dokładności możemy użyć średniej
accuracy(x, y) = mean(onecold(model(x)).==onecold(y))
accuracy(X_train, y_train)
loss_history = []
epochs = 200
Przechodzimy do trenowania sieci
for epoch in 1:epochs
train!(loss, ps, [(X_train, y_train)], opt)
train_loss = loss(X_train, y_train)
push!(loss_history, train_loss)
end
Sprawdźmy jak wygląda funkcja straty w modelu podczas jego uczenia
Plots.plot(1:epochs, loss_history,
xlabel = "Epchos",
ylabel = "Loss",
title = "Learning curve",
legend = false)
Sprawdżmy wartość dokładności na zbiorze uczącym
accuracy(X_train, y_train)
Oraz na zbiorze testowym
accuracy(X_test, y_test)
Model ten nie jest zadowalający, więc stworzymy model w którym budowa warst będzie taka sama. Nastomiast dla warstwy wyjściowej przyjmujemy sigmoidalną funkcje aktywacji (znormalizowana funkcja wykładnicza). Za funkcje straty przyjmujemy binarną entropie krzyżową.
model2 = Chain(
Dense(9, 4, relu6),
Dense(4 ,2),
softmax
)
loss2(x, y)= Flux.mse(model2(x), y)
loss2(X_train, y_train)
ps2 = params(model2)
opt2 = NADAM()
accuracy2(x, y) = mean(onecold(model2(x)).==onecold(y));
accuracy2(X_train, y_train)
loss_history2 = []
epochs = 400
for epoch in 1:epochs
train!(loss2, ps2, [(X_train, y_train)], opt2)
train_loss = loss2(X_train, y_train)
push!(loss_history2, train_loss)
end
Plots.plot(1:epochs, loss_history2,
xlabel = "Epchos",
ylabel = "Loss",
title = "Learning curve",
legend = false)
accuracy2(X_train, y_train)
accuracy2(X_test, y_test)
Obserwujemy, że po zmienie funkcji straty uzyskujemy lepsze wyniki. Kolejną modyfikacją będzie zmiana budowy naszej sieci. Wprowadzamy dodatkową warstwe ukrytą.
model3 = Chain(
Dense(9, 6, relu6),
Dense(6, 4),
Dense(4, 2),
softmax
)
loss3(x, y)= Flux.mse(model3(x), y)
loss3(X_train, y_train)
ps3 = params(model3)
opt3 = NADAM(0.01)
accuracy3(x, y) = mean(onecold(model3(x)).==onecold(y));
accuracy3(X_train, y_train)
loss_history3 = []
epochs = 300
for epoch in 1:epochs
train!(loss3, ps3, [(X_train, y_train)], opt3)
train_loss = loss3(X_train, y_train)
push!(loss_history3, train_loss)
end
Plots.plot(1:epochs, loss_history3)
accuracy3(X_train, y_train)
accuracy3(X_test, y_test)
Ten model posiada już zadowolające nas wyniki, użyjemy go do sprawdzenia, które piosenki w danej playliście mogą nam się spodobać. Wgrajmy dane o playliście "New music Friday Polska" oraz zastosujmy przekształcenia takie, jak na zbiorach uczących.
new_music_polska_raw = CSV.read("C:\\Users\\domi\\Desktop\\studia_sgh\\sem2\\new_music_polska.csv", DataFrame);
new_music_polska = new_music_polska_raw;
new_music_polska[!, :danceability] = convert(Vector{Float64}, new_music_polska[!, :danceability]);
new_music_polska[!, :energy] = convert(Vector{Float64}, new_music_polska[!, :energy]);
new_music_polska[!, :speechiness] = convert(Vector{Float64}, new_music_polska[!, :speechiness]);
new_music_polska[!, :acousticness] = convert(Vector{Float64}, new_music_polska[!, :acousticness]);
new_music_polska[!, :instrumentalness] = convert(Vector{Float64}, new_music_polska[!, :instrumentalness]);
new_music_polska[!, :tempo] = convert(Vector{Float64}, new_music_polska[!, :tempo]);
new_music_polska[!, :loudness] = convert(Vector{Float64}, new_music_polska[!, :loudness]);
new_music_polska[!, :liveness] = convert(Vector{Float64}, new_music_polska[!, :liveness]);
new_music_polska[!, :valence] = convert(Vector{Float64}, new_music_polska[!, :valence]);
new_music_polska = select(new_music_polska, :danceability, :energy, :speechiness, :acousticness, :instrumentalness, :tempo, :loudness, :liveness, :valence);
new_music_polska = mapcols(zscore, new_music_polska);
new_music_polska = Matrix(new_music_polska)' ;
likes = model3(new_music_polska)
likes = onecold(likes).-1
Powstały wektor dodajemy do pierwotnego zbioru z tytułami, filtrujemy po jedynkach. Za pomocą modelu dostajemy 38 utworów, które powinny nam się spodobać
likes_new_polska = hcat(new_music_polska_raw, likes);
select(filter(:x1 => ==(1), likes_new_polska), :name, :artist)
Powstała playlista została przez nas przesłuchana i utwory przypadły do gustu - jednak może mieć na to wpływ fakt, że lubimy muzykę radiową.
Sprawdźmy jeszcze jak nasz model oceni nasze zainteresowanie gatunkiem, którego też nie jesteśmy wielkim fanem - RAP (aczkolwiek już prędzej go posłuchamy, niż heavy metal lub disco polo)
rap = CSV.read("C:\\Users\\domi\\Desktop\\studia_sgh\\sem2\\rap.csv", DataFrame);
rap[!, :danceability] = convert(Vector{Float64}, rap[!, :danceability]);
rap[!, :energy] = convert(Vector{Float64}, rap[!, :energy]);
rap[!, :speechiness] = convert(Vector{Float64}, rap[!, :speechiness]);
rap[!, :acousticness] = convert(Vector{Float64}, rap[!, :acousticness]);
rap[!, :instrumentalness] = convert(Vector{Float64}, rap[!, :instrumentalness]);
rap[!, :tempo] = convert(Vector{Float64}, rap[!, :tempo]);
rap[!, :loudness] = convert(Vector{Float64}, rap[!, :loudness]);
rap[!, :liveness] = convert(Vector{Float64}, rap[!, :liveness]);
rap[!, :valence] = convert(Vector{Float64}, rap[!, :valence]);
rap = select(rap, :danceability, :energy, :speechiness, :acousticness, :instrumentalness, :tempo, :loudness, :liveness, :valence);
rap = mapcols(zscore, rap);
rap = Matrix(rap)';
like_rap = model3(rap);
like_rap = onecold(like_rap).-1
mean(like_rap)
Nasz model twierdzi, że tylko 26% piosenek z wybranej przez nas rapowej playlisty przypadnie nam do gustu - myślimy że ten wynik jest bardzo trafny.
W prezentowanym raporcie zmierzyliśmy się z problemem doboru nowych piosenek biorąc pod uwagę nasz gust. Naszym celem było stworzenie modelu, który na podstawie informacji ze Spotify o tym, jakie piosenki lubimy oraz jakich nie, wskaże utwory z konkretnej playlisty które nam się mogą spodobać.
Do tego wykorzystaliśmy sieci klasyfikacyjne. Pierwszy model, który zbudowaliśmy składał się z 1 warstwy wejściowej, 1 warstw ukrytej oraz 1 warstwy wyjściowej. Za funkcje aktywacji na warstwie wejściowej i ukrytej przyjęliśmy funkcje relu6, a za funkcje aktywacji na warstwie wyjściowej softmax. Wykorzystaną funkcją straty była funkcja binary_focal_loss, optymizatorem funkcja NADAM. Dokładność modelu na zbiorze uczącym wyniosła 0.573 a na zbiorze testowym 0.579. Wynik ten nas nie zadowolił dlatego stworzyliśmy kolejny model oparty na sieci o tych samych warstwach.
Model 2 składał się również z 1 warstwy wejściowej, 1 warstw ukrytej oraz 1 warstwy wyjściowej. Za funkcje aktywacji na warstwie wejściowej i ukrytej przyjęliśmy funkcje relu6, za funkcje aktywacji na warstwie wyjściowej softmax, a za optymizator NADAM(). Za funkcje starty przyjęliśmy MSE. Dokładność modelu na zbiorze uczącym wyniosła 0.634, a na zbiorze testowym 0.6234. Zatem po zmianie funkcji starty - uzyskaliśmy lepsze wyniki.
W modelu 3 wprowadziliśmy modyfikacje do struktury naszej sieci i dodaliśmy 1 warstwę ukrytą. Pozostałe funkcje aktywacji oraz straty pozostały bez zmian. W optymizatorze domyślny parametr 0.001 zamieniliśmy na 0.01. Otrzymane wartości dokładności to 0.90 na zbiorze uczącym się oraz 0.82 na zbiorze testowym. Takie wyniki zostały uznane przez nas za satysfakcjonujące.
Następnie model 3 wykorzystaliśmy do tego, aby z playlisty z nowymi utworami - New music friday Polska- zostały wybrane utwory, które mogą nam się spodobać. Model słusznie trafił w nasze gusta.
Kolejnym krokiem było przekonanie się jak nasz model oceni sympatię do gatunku, który raczej nie należy do preferowanych przez nas. Model pokazał, że 26% piosenek z playlisty z muzyką z gatunku rap może nam się spodobać. Jest to jak najbardziej zgodne z naszym gustem oraz muzyką jakiej słuchamy przeważnie.
Podsumowując oceniamy model 3 jako dobry i satysfakcjonujący. Model ten poprawnie wybrał nowe, potencjalnie ulubione piosenki oraz trafnie ocenił zainteresowanie nowym gatunkiem.